Skip to content

[Perf] Cache PropertyChanged/ChangingEventArgs on BindableProperty - #34136

Open
simonrozsival wants to merge 4 commits into
net11.0from
dev/simonrozsival/cache-eventargs
Open

[Perf] Cache PropertyChanged/ChangingEventArgs on BindableProperty#34136
simonrozsival wants to merge 4 commits into
net11.0from
dev/simonrozsival/cache-eventargs

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Feb 19, 2026

Copy link
Copy Markdown
Member

Note

Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Fixes #34092

Description

Every SetValue call allocates new PropertyChangedEventArgs and PropertyChangingEventArgs objects. Since BindableProperty.PropertyName is immutable, these are now cached on the BindableProperty instance via lazy field ??= fields.

Changes

  • BindableProperty.cs — two new cached fields: CachedPropertyChangedEventArgs and CachedPropertyChangingEventArgs, lazily initialized on first access
  • BindableObject.cs — new internal OnPropertyChanged(PropertyChangedEventArgs) and OnPropertyChanging(PropertyChangingEventArgs) overloads that accept pre-allocated args; SetValueCore and ClearValue updated to use the cached args

Benchmark

Method Mean Gen0 Allocated Alloc Ratio
Current (new per call) 5,105 ns 3.8223 24,000 B 1.00
Cached on BindableProperty 1,142 ns - 0 B 0.00

4.5× faster, zero allocation for PropertyChanged args.

@github-actions

github-actions Bot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34136

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34136"

@simonrozsival simonrozsival changed the title [WIP][Perf] Cache PropertyChanged/ChangingEventArgs on BindableProperty [Perf] Cache PropertyChanged/ChangingEventArgs on BindableProperty Mar 11, 2026
@simonrozsival
simonrozsival marked this pull request as ready for review March 11, 2026 10:40
Copilot AI review requested due to automatic review settings March 11, 2026 10:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR targets allocation/performance hot paths in BindableObject.SetValue/ClearValue by caching PropertyChangedEventArgs and PropertyChangingEventArgs per BindableProperty, so repeated property updates don’t allocate new event-args instances.

Changes:

  • Add lazy cached PropertyChangedEventArgs/PropertyChangingEventArgs instances on BindableProperty.
  • Add new BindableObject overloads that raise events using pre-allocated event args.
  • Update SetValueActual, OnBindablePropertySet, and ClearValueCore to use cached event args.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/Controls/src/Core/BindableProperty.cs Adds lazy per-property cached event-args accessors.
src/Controls/src/Core/BindableObject.cs Uses cached event args via new overloads during SetValue/ClearValue event raising.

Comment thread src/Controls/src/Core/BindableObject.cs Outdated
{
property.PropertyChanging?.Invoke(this, original.Value, newValue);
OnPropertyChanging(property.PropertyName);
OnPropertyChanging(property.CachedPropertyChangingEventArgs);

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These calls now use the new internal overload OnPropertyChanging(PropertyChangingEventArgs) instead of the existing protected virtual OnPropertyChanging(string). That bypasses overrides in derived types (e.g., Cell.OnPropertyChanging) and can change behavior during SetValue/ClearValue. Keep invoking the virtual OnPropertyChanging(string) hook (or refactor so the cached args path still routes through the virtual method) so derived behavior remains intact while avoiding allocations.

Suggested change
OnPropertyChanging(property.CachedPropertyChangingEventArgs);
OnPropertyChanging(property.PropertyName);

Copilot uses AI. Check for mistakes.
Comment thread src/Controls/src/Core/BindableObject.cs Outdated
property.PropertyChanging?.Invoke(this, original, value);

OnPropertyChanging(property.PropertyName);
OnPropertyChanging(property.CachedPropertyChangingEventArgs);

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using OnPropertyChanging(property.CachedPropertyChangingEventArgs) calls the new non-virtual overload and bypasses protected virtual OnPropertyChanging(string) overrides in derived classes. This is a behavioral breaking change (e.g., Cell relies on OnPropertyChanging("Parent")). The allocation optimization needs to preserve the virtual hook; consider routing cached args through the existing virtual path (or introducing a new virtual core method that derived types can override without breaking existing overrides).

Suggested change
OnPropertyChanging(property.CachedPropertyChangingEventArgs);
OnPropertyChanging(property.PropertyName);

Copilot uses AI. Check for mistakes.
Comment thread src/Controls/src/Core/BindableObject.cs Outdated
if (willFirePropertyChanged)
{
OnPropertyChanged(property.PropertyName);
OnPropertyChanged(property.CachedPropertyChangedEventArgs);

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OnBindablePropertySet now calls the new internal OnPropertyChanged(PropertyChangedEventArgs) overload, which directly raises PropertyChanged and bypasses the existing protected virtual OnPropertyChanged(string) hook. Many controls (notably Element) override OnPropertyChanged(string) for important behavior (effects, handler updates), so this changes runtime semantics and will likely break tests. The cached-args optimization should still invoke the virtual OnPropertyChanged(string) override chain (or otherwise preserve that extensibility point) while avoiding per-call allocations.

Suggested change
OnPropertyChanged(property.CachedPropertyChangedEventArgs);
OnPropertyChanged(property.PropertyName);

Copilot uses AI. Check for mistakes.
simonrozsival and others added 3 commits March 20, 2026 10:07
…bleProperty

Fixes #34092

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… caching

The Element.cs change (_currentPropertyBeingSet tracking) was fragile.
Keep the core optimization: cache PropertyChanged/ChangingEventArgs on
BindableProperty and use them from BindableObject.SetValueCore.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/cache-eventargs branch from 8697409 to 896c058 Compare March 20, 2026 09:08
The previous implementation bypassed OnPropertyChanged/OnPropertyChanging
virtual overrides in 20+ derived classes. This fix moves the caching to
static dictionaries keyed by property name, and uses them inside the
existing virtual methods so derived class overrides are still called.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival

Copy link
Copy Markdown
Member Author

@mattleibow @StephaneDelcroix could you please have a look at this PR and let me know what you think about it? could we merge this into net11.0 soon?


internal ValidateValueDelegate ValidateValue { get; private set; }

private static readonly ConcurrentDictionary<string, PropertyChangedEventArgs> s_changedArgsCache = new();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we should set up a max value here. For Maui properties, this can be fixed size and will not cause any memory pressure, but for apps, the large ones mainly, with custom controls and different property names, it can cause a memory pressure. In the wild, I can say that people choose funny names for their properties; for example, instead of choosing Text as a property name, it can be Title, TitleText, etc., causing this collection to grow and may cause a memory pressure at some point.

@kubaflo

kubaflo commented May 24, 2026

Copy link
Copy Markdown
Contributor

/review -b feature/refactor-copilot-yml

@MauiBot

This comment has been minimized.

@kubaflo

kubaflo commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

AI code review for net11.0 target

Verdict: Needs discussion (one subtle behavior change to confirm; otherwise a clean, well-scoped perf change)

This is an independent review (diff-first, then reconciled with the PR narrative). It is not an approval — a human still needs to sign off.

What the PR does

Caches PropertyChangedEventArgs/PropertyChangingEventArgs per property name in two static ConcurrentDictionarys and reuses them from BindableObject.OnPropertyChanged/OnPropertyChanging, avoiding a per-notification allocation. Solid, common optimization (WPF does the same).

Findings

  • Possible regression for propertyName == null. ConcurrentDictionary<string,…>.GetOrAdd(null, …) throws ArgumentNullException, whereas the previous new PropertyChangedEventArgs(propertyName) accepted null (the documented INPC convention meaning "all properties changed"). OnPropertyChanged/OnPropertyChanging are protected virtual and reachable by user subclasses, so a OnPropertyChanged(null) call that worked before would now throw. No internal callers pass null (checked), and ""/string.Empty keys are unaffected, so impact is narrow — but it’s a behavior change in a very hot, widely-overridden API. Suggest a fast-path that bypasses the cache when propertyName is null (return new PropertyChangedEventArgs(null)), or document the change.
  • Unbounded cache growth is effectively bounded in practice (property names are compile-time constants), so not a real concern; worth a one-line comment noting the assumption.

CI

All required maui-pr legs are green (build, Helix unit tests, integration tests across platforms). No PR-attributable failures.

Confidence: high on the analysis; the only open item is the null property-name edge case.

@kubaflo kubaflo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NEEDS_DISCUSSION (confidence: medium) — multi-model review (3 ND, 1 NC); CI green.

This is a clean, well-scoped perf change: it caches PropertyChanged/PropertyChanging event args by name in two static ConcurrentDictionarys and reuses them from the hot OnPropertyChanged/OnPropertyChanging paths, cutting per-notification allocations.

Points to resolve before merge

  • (warning) Null property name throws. GetOrAdd(propertyName, …) rejects null keys; raising a change notification with a null/empty name (the .NET "all properties changed" convention) would now throw ArgumentNullException where it previously didn't. Add a null guard.
  • (warning) Unbounded static-cache growth. Keyed by arbitrary strings off the public API, the cache can grow without bound for dynamically-named properties (indexers, generated names). Consider bounding it or restricting caching to known BindableProperty names. (3/4 models flagged this; gemini rated it higher.)

Otherwise the optimization is sound and tests/CI are green.

private static readonly ConcurrentDictionary<string, PropertyChangingEventArgs> s_changingArgsCache = new();

internal static PropertyChangedEventArgs GetCachedPropertyChangedEventArgs(string propertyName)
=> s_changedArgsCache.GetOrAdd(propertyName, static name => new PropertyChangedEventArgs(name));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConcurrentDictionary<string,T>.GetOrAdd(propertyName, ...) throws ArgumentNullException when propertyName is null. OnPropertyChanged/OnPropertyChanging are protected virtual and, by .NET convention, can be raised with a null/empty property name to signal "all properties changed". Before this change that path was harmless; now it would throw. Please null-guard propertyName before the cache lookup (e.g. fall back to new PropertyChangedEventArgs(propertyName) when null).


internal ValidateValueDelegate ValidateValue { get; private set; }

private static readonly ConcurrentDictionary<string, PropertyChangedEventArgs> s_changedArgsCache = new();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These caches are static (app-lifetime) and keyed by arbitrary strings flowing through the public OnPropertyChanged/OnPropertyChanging surface. For dynamically-named properties (e.g. indexer notifications like Item[0], Item[1], … or generated names) this grows unboundedly for the life of the process. Consider bounding the cache, or only caching for known BindableProperty.PropertyName values rather than arbitrary strings. (Raised by 3 of the 4 review models.)

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Review — 2 findings

See inline comments for details.

internal static PropertyChangedEventArgs GetCachedPropertyChangedEventArgs(string propertyName)
=> s_changedArgsCache.GetOrAdd(propertyName, static name => new PropertyChangedEventArgs(name));

internal static PropertyChangingEventArgs GetCachedPropertyChangingEventArgs(string propertyName)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Memory Leak Prevention / Performance — These static caches are populated through the protected BindableObject.OnPropertyChanged(string) / OnPropertyChanging(string) hooks, so app or derived-control code can pass arbitrary property-name strings. Dynamic names such as indexer notifications (Item[123]) or generated names would now be retained for the app lifetime, whereas before the event args were short-lived. Since the optimization only needs known BindableProperty.PropertyName notifications, please scope caching to those known properties or avoid caching arbitrary protected-API strings.

MauiBot

This comment was marked as outdated.

@simonrozsival

Copy link
Copy Markdown
Member Author

The concern about indefinitely caching event args for properties that only fire once makes sense as a follow-up tradeoff to keep an eye on.

I don't think we should complicate this PR with eviction yet unless we see evidence that one-shot property names meaningfully accumulate in real apps. The current change keeps the implementation simple and removes allocation churn for the common hot paths.

If this does show up as retained memory in profiling, two reasonable follow-up designs would be:

  • a fixed-size LRU cache, so only the most frequently used properties remain cached; or
  • a short time-bound LRU cache, so bursts of property changes during page load, layout, or touch interactions can reuse cached args, but entries that have not been used recently are drained afterward.

A time window on the order of tens of milliseconds, e.g. ~50ms, might be enough to cover bursty UI activity without keeping rare entries indefinitely, but I would prefer to validate that with allocation/retention data before adding the extra complexity.

@github-actions github-actions Bot added the s/agent-ready-for-rerun AI review has a new PR-author comment or commit and is ready for rerun label Jul 8, 2026
@github-actions github-actions Bot removed the s/agent-ready-for-rerun AI review has a new PR-author comment or commit and is ready for rerun label Jul 8, 2026
@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Jul 23, 2026
@MauiBot

This comment has been minimized.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Jul 24, 2026
kubaflo pushed a commit that referenced this pull request Jul 24, 2026
A recurring android/mac gate failure is DotNet.csproj MSB6003
'System.IO.Pipes.dll ... cannot find the file': the shared
Microsoft.NETCore.App pack was provisioned WITHOUT System.IO.Pipes.dll
(an assembly that always ships in that framework). 'dotnet --version'
still succeeds (it never loads that assembly) and the SDK carries a valid
cake stamp, so cake REUSES the corrupt SDK and every build that spawns a
process (Exec 'sh') dies.

On the GATE stage this blocks the whole review: setup never completes,
the sentinel is never written, and NO review is posted. Observed on
#34136 (build 14753151 — last review was June 20, this build posted
nothing) and #36657. The existing 'Prepare .dotnet' step can't catch it:
it is windows-only and its --version probe can't see a missing runtime
assembly.

Add a non-windows, best-effort (continueOnError) pre-step that checks
each installed Microsoft.NETCore.App pack for System.IO.Pipes.dll and, if
missing, removes ./.dotnet so the build step reinstalls a complete SDK.
It only ever deletes a provably-incomplete SDK and can never fail the
build itself.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 15d2af20-e4ab-4e88-9011-cfbd83513bc0
@MauiBot

This comment has been minimized.

kubaflo pushed a commit that referenced this pull request Jul 24, 2026
In deferred posting, when the gate/review agent produced no PRAgent
content (e.g. the Review stage failed before uploading its logs), the
'no PRAgent dir' branch only LOGGED 'falling back to posting deep results
only' and posted NOTHING — the actual posting lives entirely in the else
branch. So a build that ran the full deep suite could leave the author
with zero feedback (observed on #34136 build 14755367: deep succeeded,
review never posted).

Post a standalone deep-results review in that branch so the deep outcome
is always surfaced, with an honest note that the full AI summary didn't
generate (infra) and to re-comment /review. Best-effort (try/catch +
LASTEXITCODE reset) so it can never fail the Post task; scoped entirely to
the previously-no-op branch so the normal (PRAgent-present) path is
untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 15d2af20-e4ab-4e88-9011-cfbd83513bc0
kubaflo pushed a commit that referenced this pull request Jul 24, 2026
…ults post

Per feedback on #34136: when a review stage fails, the PR was left showing
only the bare 'Automated review could not complete — no review summary was
produced' notice, even though the deep UI tests ran. The deferred-post
fallback (385d70d) posts a standalone deep-results review in that case,
but it did NOT collapse the earlier review-incomplete notice — so the PR
ended with BOTH a real deep-results review AND a contradictory 'no summary
produced' warning.

After the standalone deep-results review posts, collapse the review-incomplete
notice (Hide-StaleMauiBotIssueComments -IncludeReviewIncomplete) so the PR is
FINISHED with an actual summary — matching what the normal post path already
does. Best-effort; never fatal. When there are genuinely no deep results
either, the notice is left as the honest outcome.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 15d2af20-e4ab-4e88-9011-cfbd83513bc0
@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Jul 24, 2026
@MauiBot

This comment has been minimized.

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

ℹ️ The review agent did not produce a full summary on this run (an infrastructure issue on the CI agent), but the deep UI tests completed — their results are below. Re-comment /review for a fresh full review.

Deep UI tests — 354 passed, 0 failed across 3 categories on platform-pool agent (replaces in-process counts above).

🧪 UI Test Execution Results (deep, platform pool)

Category Tests Snapshot diffs
Button 67/69 ✓ 1 diff PNG
Label 96/98 ✓
Layout 191/194 ✓
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs)

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Jul 24, 2026
@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Aug 22, 2026

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Review — 6 findings

See inline comments for details.

=> s_changingArgsCache.GetOrAdd(propertyName, static name => new PropertyChangingEventArgs(name));

// Properties that this property depends on - when getting this property's value,
// if the dependency has a pending binding, return the default value instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[critical] Logic and CorrectnessConcurrentDictionary<string,T>.GetOrAdd throws ArgumentNullException when the key is null, so a null propertyName now crashes where it previously worked. BindableObject.OnPropertyChanged(string propertyName = null) / OnPropertyChanging(string propertyName = null) are protected virtual with a null default, and derived overrides forward a nullable value straight to base (e.g. Border.cs:443, BoxView.cs:150, RefreshView.cs:187, RadioButton.cs:377 all call base.OnPropertyChanged(propertyName) with string?). Concrete scenario: a subclass (in-tree or third-party) calls OnPropertyChanged(null) — the documented INotifyPropertyChanged idiom meaning "all properties changed" — or forwards a null variable; before this change new PropertyChangedEventArgs(null) was valid and the event was raised, now the call throws. The same applies to GetCachedPropertyChangingEventArgs on line 269. Guard the null case (return a cached args instance built for null, or fall back to new PropertyChangedEventArgs(propertyName) when propertyName is null).

internal static PropertyChangedEventArgs GetCachedPropertyChangedEventArgs(string propertyName)
=> s_changedArgsCache.GetOrAdd(propertyName, static name => new PropertyChangedEventArgs(name));

internal static PropertyChangingEventArgs GetCachedPropertyChangingEventArgs(string propertyName)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[major] Memory Leak Prevention — These are process-lifetime static caches with no eviction and no bound, keyed on an arbitrary caller-supplied string. OnPropertyChanged/OnPropertyChanging are public-surface (protected virtual) entry points, so any BindableObject subclass that raises change notifications with a dynamically composed name — indexer-style names such as $"Item[{i}]", names derived from collection items, or per-instance generated names — permanently adds one dictionary entry plus one PropertyChangedEventArgs/PropertyChangingEventArgs per distinct string, and neither the entry nor the string is ever released even after every object using it is collected. Unlike the previous per-call allocation (gen0, collected immediately), this converts a transient allocation into an unbounded gen2-rooted one. If the intent is to cache only the framework's fixed set of names, key the cache off the BindableProperty instance (which already owns a stable PropertyName) or store the args in a field on BindableProperty, and allocate normally for names that do not come from a registered property.

internal static PropertyChangingEventArgs GetCachedPropertyChangingEventArgs(string propertyName)
=> s_changingArgsCache.GetOrAdd(propertyName, static name => new PropertyChangingEventArgs(name));

// Properties that this property depends on - when getting this property's value,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Performance-Critical Path — This replaces a small gen0 allocation on the property-change path with a string hash plus ConcurrentDictionary lookup on every notification, which is not obviously a win: PropertyChangedEventArgs is a 2-field object that the gen0 allocator handles in a few instructions, while GetOrAdd costs a full string hash (proportional to name length) plus a bucket probe and comparison. A cheaper design exists for the dominant call path — BindableObject.SetValue raises notifications with property.PropertyName, so the args instance could be stored in a field on the BindableProperty itself and reused with zero lookup. Since the change is justified purely on performance, it needs measured evidence (dotnet-trace / BenchmarkDotNet on a property-change-heavy scenario) showing the dictionary lookup beats the allocation it removes; otherwise this is a behavior-changing rewrite of a hot path with no proven benefit.

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

@simonrozsival — new AI review results are available based on commit e3e616a.

Gate No Tests Confidence Low Platform MacCatalyst


🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix

Gate Result: ⚠️ SKIPPED

No tests were detected in this PR.

Recommendation: Add tests to verify the fix using the write-tests-agent.


📋 Pre-Flight — Context & Validation

Issue: #34092 - [Perf] Cache PropertyChangedEventArgs and PropertyChangingEventArgs on BindableProperty
PR: #34136 - [Perf] Cache PropertyChanged/ChangingEventArgs on BindableProperty
Base / Head: net11.0 / squashed review commit 249f0bcdc8
Requested Platform: Mac Catalyst (catalyst)
Files Changed: 2 implementation, 0 test
Gate: SKIPPED - no tests detected in the PR; the gate was not re-run.

Current PR Approach

BindableObject.OnPropertyChanged(string) and OnPropertyChanging(string) preserve their existing virtual dispatch but obtain event arguments from two static ConcurrentDictionary<string, TEventArgs> caches on BindableProperty. The change removes repeated event-argument allocations for repeated property names.

diff --git a/src/Controls/src/Core/BindableObject.cs b/src/Controls/src/Core/BindableObject.cs
@@
-			=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+			=> PropertyChanged?.Invoke(this, BindableProperty.GetCachedPropertyChangedEventArgs(propertyName));
@@
-			=> PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName));
+			=> PropertyChanging?.Invoke(this, BindableProperty.GetCachedPropertyChangingEventArgs(propertyName));
diff --git a/src/Controls/src/Core/BindableProperty.cs b/src/Controls/src/Core/BindableProperty.cs
@@
+		private static readonly ConcurrentDictionary<string, PropertyChangedEventArgs> s_changedArgsCache = new();
+		private static readonly ConcurrentDictionary<string, PropertyChangingEventArgs> s_changingArgsCache = new();
+
+		internal static PropertyChangedEventArgs GetCachedPropertyChangedEventArgs(string propertyName)
+			=> s_changedArgsCache.GetOrAdd(propertyName, static name => new PropertyChangedEventArgs(name));
+
+		internal static PropertyChangingEventArgs GetCachedPropertyChangingEventArgs(string propertyName)
+			=> s_changingArgsCache.GetOrAdd(propertyName, static name => new PropertyChangingEventArgs(name));

Key Findings and Constraints

  • ConcurrentDictionary.GetOrAdd(null, ...) throws ArgumentNullException; previously the protected virtual notification methods accepted null, the standard "all properties changed" notification.
  • The static dictionaries retain arbitrary names supplied through protected virtual APIs for process lifetime. Dynamic/indexer-like names can therefore cause unbounded retained memory.
  • Earlier review findings about bypassing OnPropertyChanged(string) / OnPropertyChanging(string) overrides apply to an older revision and are fixed in the current patch.
  • The issue proposed caching on each BindableProperty, but the current PR caches globally by arbitrary string. Alternative candidates should preserve virtual dispatch, null-name behavior, and bounded retention while still removing allocations from the SetValue hot path.
  • No public API surface is intentionally added.

Prior Discussion

  • Reviewers repeatedly requested a null guard and restriction or bounding of the global cache.
  • The author preferred not to add LRU/expiry complexity without retention measurements.
  • Existing CI was previously reported green, but no PR test directly verifies allocation reuse, null names, or bounded retention.

Targeted Validation

Primary and regression test command:

dotnet test src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj --filter "FullyQualifiedName~BindableObjectUnitTests|FullyQualifiedName~BindablePropertyUnitTests"

Do not run the gate or a full test suite. The existing test project is platform-neutral; catalyst remains the requested target-platform context.

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #34136 Static dictionaries keyed by every notification name SKIPPED (gate found no tests) BindableObject.cs, BindableProperty.cs Null-name exception and unbounded retention unresolved

🔬 Code Review — Deep Analysis

Code Review — PR #34136

Independent Assessment

What this changes: Replaces per-notification PropertyChangedEventArgs and PropertyChangingEventArgs allocations in the protected virtual BindableObject notification methods with two process-wide ConcurrentDictionary<string, ...> caches hosted on BindableProperty.

Inferred motivation: Reduce allocation churn on the SetValue and ClearValue hot paths while preserving existing virtual dispatch.

Approach assessment: The current implementation is not safe to merge. It fixes the earlier override-bypass design, but it broadens caching from the finite set of BindableProperty instances to every arbitrary string supplied through protected APIs. That creates a concrete null-name regression and app-lifetime retention of unbounded caller-provided names. A cache scoped to the active BindableProperty, with uncached fallback behavior for direct protected notifications, better matches the optimization target.

Reconciliation with PR Narrative

Author claims: Event args are cached on each BindableProperty through lazy instance fields, and SetValueCore/ClearValue use new event-args overloads.

Agreement/disagreement: The motivation is consistent with the code, but the description is stale. The submitted HEAD instead uses static dictionaries keyed by property-name strings and does not add the described overloads or per-property fields. Its benchmark therefore does not establish the performance of the submitted dictionary implementation.

Prior Review Reconciliation

Prior finding Source Status Evidence
New event-args overloads bypass protected virtual notification hooks Copilot inline review ✅ Fixed Current SetValue/ClearValue paths still call OnPropertyChanged(string) and OnPropertyChanging(string).
Null notification names throw in ConcurrentDictionary.GetOrAdd kubaflo and MauiBot ❌ Unresolved BindableProperty.cs:266,269 pass the nullable protected-method argument directly as a dictionary key.
Static caches retain arbitrary dynamic names for the app lifetime pictos, kubaflo, and MauiBot ❌ Unresolved BindableProperty.cs:262-263 are static, unbounded dictionaries reached by protected virtual APIs.

Blast Radius Assessment

  • Runs for all instances: Yes. Every base BindableObject.OnPropertyChanged and OnPropertyChanging call uses the caches, not only framework SetValue notifications.
  • Startup impact: No direct startup initialization; dictionaries initialize on first BindableProperty type use and populate on notifications.
  • Static/shared state: Yes. Both dictionaries are process-wide and retain keys and event args for the process lifetime.
  • Platform scope: Shared Controls infrastructure; behavior affects every platform, including Mac Catalyst.

CI Status

  • Required-check result: Current public check runs on live HEAD are completed successfully, including maui-pr; authenticated required-check classification was unavailable.
  • Trusted Gate result: ⚠️ SKIPPED because the PR adds no tests.
  • Classification: Relevant regression coverage is undetermined. No test exercises null names, arbitrary dynamic names, or shared event-args reuse.
  • Action taken: Confidence capped at low; Gate verification was not re-run.

Findings

❌ Error — Null notification names now throw

OnPropertyChanged(null) and OnPropertyChanging(null) previously constructed valid event args and raised the event. They now reach ConcurrentDictionary.GetOrAdd(null, ...), which throws ArgumentNullException. These protected virtual methods explicitly default to null and derived controls forward nullable values to base, so this is a concrete public-surface regression.

⚠️ Warning — Process-wide caches have an unbounded caller-controlled key space

The protected notification methods accept arbitrary names, including generated indexer names. Each distinct string is now rooted by both static dictionaries for the app lifetime, converting transient allocations into permanent retention.

⚠️ Warning — Hot-path benefit of dictionary lookup is not established

Every notification now pays string hashing and concurrent dictionary lookup. The supplied benchmark describes a different per-BindableProperty implementation, so it does not demonstrate that the submitted implementation improves the hot path.

⚠️ Warning — No regression coverage accompanies the behavior change

The PR adds no tests for null and empty names, repeated reuse, concurrent raises, or dynamic-name retention.

💡 Discussion — Event-args identity becomes globally shared

Subscribers previously received a fresh args object per raise. The PR intentionally changes that observable identity and lifetime but does not document the compatibility tradeoff.

Failure-Mode Probing

  • A subclass calls OnPropertyChanged(null): the dictionary rejects the key before any event is raised.
  • A custom control emits $"Item[{index}]" names over time: both static dictionaries retain every distinct string and args object after the controls are collected.
  • Multiple objects raise the same property concurrently: immutable args are safe to share, but all callers pay the concurrent lookup and observe globally shared identity.
  • A derived override forwards to base: virtual dispatch is preserved in the current revision, but nullable forwarding still reaches the null-key exception.

External Output Contract

Not applicable.

Verdict: NEEDS_CHANGES

Confidence: low

Summary: The null-key crash is a verified regression and the unbounded static retention is unresolved. Because this changes shared notification infrastructure and the trusted Gate detected no tests, the safety recommendation remains low-confidence even though the defects themselves are concrete.


🛠️ Try-Fix — Analysis & Comparison

Try-Fix Aggregate — PR #34136

Bound: at most two candidates; sequential single-pass attempts.
Gate: not re-run (already skipped because the PR adds no tests).
Targeted command: dotnet test src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj --filter "FullyQualifiedName~BindableObjectUnitTests|FullyQualifiedName~BindablePropertyUnitTests"

# Model Approach Result Test Diff Self-review
1 claude-opus-5 Cache event args on each BindableProperty; pass the active property through a scoped, re-entrancy-safe notification hint while retaining virtual string hooks Blocked Not run: baseline was not established Empty 0 findings
2 gpt-5.6-sol Replace strong dictionaries with weak-key ConditionalWeakTable caches keyed by notification-string identity, with an explicit null path Blocked Not run: baseline was not established Empty 0 findings

Candidate 1 — Per-BindableProperty Cache with Scoped Notification Hint

Result: Blocked

The proposed mechanism moves cache identity from arbitrary caller-controlled strings in process-wide dictionaries to the owning BindableProperty. Each property would lazily publish immutable changed/changing event args, while a save/restore hint around the existing virtual call would let the base notifier use those args only for framework SetValue notifications. Null and arbitrary names would retain their original collectable-allocation behavior, declared-property retention would remain bounded, and override dispatch would be preserved.

This approach was not applied. EstablishBrokenBaseline.ps1 rejected 44 pre-existing unrelated tracked worktree modifications before creating .github/.baseline-state.json. Without a RevertedFiles allow-list, the skill contract prohibited editing either PR file. The exact targeted test was therefore not run, fix.diff is empty, and the script-only restore returned the permitted no-state blocked result (Restored False, no attempt edits made). Full narrative: ../try-fix-1/content.md; mandatory artifacts: attempt-1/.

Candidate 2

Weak-Key Cache by Notification-String Identity

Result: Blocked

This distinct mechanism would replace the PR's strong ConcurrentDictionary caches with ConditionalWeakTable<string, TEventArgs> caches. The existing protected virtual string methods would remain the event-raising path. A direct null branch would preserve the all-properties notification, while non-null names would be cached by object identity. Normal SetValue calls repeatedly pass the stable BindableProperty.PropertyName string instance and therefore reuse event args; caller-created names would not be kept alive by the cache, and key/value cycles remain collectible under ephemeron semantics.

This approach uses neither candidate 1's per-property fields nor its scoped current-property hint. It was not applied for the same independent environment blocker: the mandatory baseline script rejected the 44 pre-existing unrelated tracked modifications and created no restoration state or edit allow-list. The targeted test was not run, fix.diff is empty, self-review recorded 0 findings, and the script-only restore returned the permitted no-state blocked result (Restored False, no attempt edits made). Full narrative: ../try-fix-2/content.md; mandatory artifacts: attempt-2/.

Aggregate Outcome

Two mechanism-level alternatives were produced, but neither could be implemented or empirically tested because the mandatory baseline workflow refuses the pre-existing dirty worktree before isolating the two PR files. Both results are therefore Blocked, not Pass; no source or test file was changed, and no gate or full suite was run.


📝 PR Finalize — Recommended Title & Description

Assessment: ✏️ Recommend updating — the current description documents an earlier per-BindableProperty/overload implementation, but the submitted HEAD uses process-wide dictionaries keyed by property name through the existing virtual methods.

Recommended title

[Core] BindableObject: Cache property notification event args by name

Recommended description

Fixes https://github.com/dotnet/maui/issues/34092

### Description of Change

Every base `BindableObject.OnPropertyChanged` and `OnPropertyChanging` call allocates a new `PropertyChangedEventArgs` or `PropertyChangingEventArgs`. This change caches those immutable event args by property name so repeated notifications can reuse them.

### Changes

- **`BindableProperty.cs`** — adds two process-wide `ConcurrentDictionary<string, PropertyChangedEventArgs/PropertyChangingEventArgs>` caches and internal lookup methods.
- **`BindableObject.cs`** — updates the existing protected virtual `OnPropertyChanged(string)` and `OnPropertyChanging(string)` methods to retrieve cached event args while preserving virtual dispatch.

### Behavioral Impact

Event-args instances are shared across `BindableObject` instances for the same property-name string instead of being newly allocated for every notification. The cache applies to every name passed through the protected virtual notification methods, not only names originating from a registered `BindableProperty`.

🏁 Report — Final Recommendation

⚠️ Final Recommendation: REQUEST CHANGES

Winner: pr-plus-reviewer

The submitted PR has a concrete regression: OnPropertyChanged(null) and OnPropertyChanging(null) now throw because ConcurrentDictionary rejects null keys. Its process-wide caches also retain every arbitrary notification name for the app lifetime, and the trusted Gate was skipped because the PR adds no tests.

Candidate Implementation Validation Assessment
pr-plus-reviewer Per-BindableProperty lazy event args, scoped through the existing virtual string hooks; direct null/empty/arbitrary notifications preserve prior behavior; focused regression tests added PASS: 111 passed, 0 failed, 0 skipped Winner. Resolves the verified null regression and unbounded caller-controlled cache while retaining the intended hot-path reuse and virtual dispatch.
pr Two process-wide ConcurrentDictionary<string, ...> caches used by every base notification Gate SKIPPED; current public CI checks are green, but no relevant PR tests were added Not acceptable as submitted because the null-name crash and unbounded retention remain.
try-fix-1 Proposed per-BindableProperty cache with a scoped notification hint BLOCKED: no patch and no test run Sound mechanism and closest to the winner, but it was never materialized or validated.
try-fix-2 Proposed weak-key ConditionalWeakTable caches keyed by string identity with a null fallback BLOCKED: no patch and no test run Avoids strong-key retention conceptually, but it was never materialized or validated and retains a lookup on every notification.

Why pr-plus-reviewer wins

It is the only candidate that both addresses the expert review's blocking correctness and retention findings and passes the required targeted regression suite. It also preserves the public protected virtual hooks, confines cached lifetime to declared BindableProperty instances, keeps direct caller-supplied names collectable, and removes dictionary hashing from the framework SetValue/ClearValue path.

The raw PR cannot be approved because the winning changes are not present in the submitted HEAD. The two STEP 5a candidates rank below the passing candidate because both were blocked before producing code or regression evidence.


📱 UI Tests — Button,Label,Layout

Detected UI test categories: Button,Label,Layout

Deep UI tests — 23 passed, 313 failed, 10 skipped across 3 categories on platform-pool agent (replaces in-process counts above).

🧪 UI Test Execution Results (deep, platform pool)

Category Tests Snapshot diffs
Button 2/70 (67 ❌, 1 skipped) 16 diff PNGs
Label 10/86 (71 ❌, 5 skipped) 13 diff PNGs
Layout 11/190 (175 ❌, 4 skipped) 6 diff PNGs
🔍 AI analysis of failures — PR-related vs unrelated

🔍 AI-generated triage (GitHub Copilot CLI) — a heuristic judgement of whether each deep UI test failure is connected to this PR's changes. Verify before relying on it.

Likely unrelated: the failures appear pre-existing, flaky, or infrastructure.

  • ● Unrelated — Appium element and interaction timeouts across Button, Label, and Layout (300+ tests): the repeated element-wait and 45-second Tap/Click command timeouts are run-wide driver or app-responsiveness failures, with no stack path through the changed event-notification code.
  • ● Unrelated — Catalyst visual baseline and comparison-harness failures (at least 2 tests): the 2560x1571 actual image versus 789x563 baseline and the unparseable difference result indicate window-size, snapshot-baseline, or visual-harness issues rather than changed BindableObject behavior.

Strongest signal: unrelated controls fail with the same Appium timeout signature, while the only concrete visual mismatch shows a drastically different Catalyst capture size.

Button — 67 failed tests
ButtonImageTintColorPreservedAfterResize
System.TimeoutException : An Appium command did not complete within 45s. The application may be unresponsive (e.g., due to an infinite layout loop).
at UITest.Appium.HelperExtensions.RunWithTimeout[T](Func`1 action, Nullable`1 timeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 3084
   at UITest.Appium.HelperExtensions.RunWithTimeout(Action action, Nullable`1 timeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 3103
   at UITest.Appium.HelperExtensions.Tap(IApp app, String element) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 36
   at Microsoft.Maui.TestCases.Tests.Issues.Issue25093.ButtonImageTintColorPreservedAfterResize() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue25093.cs:line 18
   at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack target, Void** arguments, ObjectHandleOnStack sig, BOOL isConstructor, ObjectHandleOnStack result)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, B
...
VerifyButtonPage7
System.TimeoutException : An Appium command did not complete within 45s. The application may be unresponsive (e.g., due to an infinite layout loop).
at UITest.Appium.HelperExtensions.RunWithTimeout[T](Func`1 action, Nullable`1 timeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 3084
   at UITest.Appium.HelperExtensions.RunWithTimeout(Action action, Nullable`1 timeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 3103
   at UITest.Appium.HelperExtensions.Tap(IApp app, String element) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 36
   at Microsoft.Maui.TestCases.Tests.Issues.Issue22306_3.VerifyButtonPage7() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue22306_3.cs:line 82
   at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack target, Void** arguments, ObjectHandleOnStack sig, BOOL isConstructor, ObjectHandleOnStack result)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeA
...
Button_SetLineBreakModeWordWrap_VerifyVisualState
System.TimeoutException : Timed out waiting for element...
at UITest.Appium.HelperExtensions.Wait(Func`1 query, Func`2 satisfactory, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 2785
   at UITest.Appium.HelperExtensions.WaitForAtLeastOne(Func`1 query, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 2812
   at UITest.Appium.HelperExtensions.WaitForElement(IApp app, String marked, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency, Nullable`1 postTimeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 797
   at Microsoft.Maui.TestCases.Tests.ButtonFeatureTests.Button_SetLineBreakModeWordWrap_VerifyVisualState() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/ButtonFeatureTests.cs:line 379
   at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack target, Voi
...
ButtonsLayoutResolveWhenParentSizeChanges
System.InvalidOperationException : Unable to extract difference percentage from exception message.
at Microsoft.Maui.TestCases.Tests.UITest.VerifyScreenshot(String name, Nullable`1 retryDelay, Nullable`1 retryTimeout, Int32 cropLeft, Int32 cropRight, Int32 cropTop, Int32 cropBottom, Double tolerance, Boolean includeTitleBar) in /_/src/Controls/tests/TestCases.Shared.Tests/UITest.cs:line 299
   at Microsoft.Maui.TestCases.Tests.Issues.Issue22306.ButtonsLayoutResolveWhenParentSizeChanges() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue22306.cs:line 25
   at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack target, Void** arguments, ObjectHandleOnStack sig, BOOL isConstructor, ObjectHandleOnStack result)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
TextViewBasedControlsTextColorUpdates
System.TimeoutException : An Appium command did not complete within 45s. The application may be unresponsive (e.g., due to an infinite layout loop).
at UITest.Appium.HelperExtensions.RunWithTimeout[T](Func`1 action, Nullable`1 timeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 3084
   at UITest.Appium.HelperExtensions.RunWithTimeout(Action action, Nullable`1 timeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 3103
   at UITest.Appium.HelperExtensions.Tap(IApp app, String element) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 36
   at Microsoft.Maui.TestCases.Tests.Issues.Issue35513.TextViewBasedControlsTextColorUpdates() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35513.cs:line 18
   at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack target, Void** arguments, ObjectHandleOnStack sig, BOOL isConstructor, ObjectHandleOnStack result)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, Bind
...
VerifyButtonPage3
System.TimeoutException : An Appium command did not complete within 45s. The application may be unresponsive (e.g., due to an infinite layout loop).
at UITest.Appium.HelperExtensions.RunWithTimeout[T](Func`1 action, Nullable`1 timeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 3084
   at UITest.Appium.HelperExtensions.RunWithTimeout(Action action, Nullable`1 timeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 3103
   at UITest.Appium.HelperExtensions.Tap(IApp app, String element) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 36
   at Microsoft.Maui.TestCases.Tests.Issues.Issue22306_3.VerifyButtonPage3() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue22306_3.cs:line 42
   at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack target, Void** arguments, ObjectHandleOnStack sig, BOOL isConstructor, ObjectHandleOnStack result)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeA
...
SettingCharacterSpacingShouldNotCrash
System.TimeoutException : An Appium command did not complete within 45s. The application may be unresponsive (e.g., due to an infinite layout loop).
at UITest.Appium.HelperExtensions.RunWithTimeout[T](Func`1 action, Nullable`1 timeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 3084
   at UITest.Appium.HelperExtensions.RunWithTimeout(Action action, Nullable`1 timeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 3103
   at UITest.Appium.HelperExtensions.Tap(IApp app, String element) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 36
   at Microsoft.Maui.TestCases.Tests.Issues.Issue31238.SettingCharacterSpacingShouldNotCrash() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31238.cs:line 18
   at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack target, Void** arguments, ObjectHandleOnStack sig, BOOL isConstructor, ObjectHandleOnStack result)
   at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, Bind
...
Button_SetLineBreakModeHeadTruncation_VerifyVisualState
System.TimeoutException : Timed out waiting for element...
at UITest.Appium.HelperExtensions.Wait(Func`1 query, Func`2 satisfactory, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 2785
   at UITest.Appium.HelperExtensions.WaitForAtLeastOne(Func`1 query, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 2812
   at UITest.Appium.HelperExtensions.WaitForElement(IApp app, String marked, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency, Nullable`1 postTimeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 797
   at Microsoft.Maui.TestCases.Tests.ButtonFeatureTests.Button_SetLineBreakModeHeadTruncation_VerifyVisualState() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/ButtonFeatureTests.cs:line 328
   at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack targe
...
Button_SetLineBreakModeTailTruncation_VerifyVisualState
System.TimeoutException : Timed out waiting for element...
at UITest.Appium.HelperExtensions.Wait(Func`1 query, Func`2 satisfactory, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 2785
   at UITest.Appium.HelperExtensions.WaitForAtLeastOne(Func`1 query, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 2812
   at UITest.Appium.HelperExtensions.WaitForElement(IApp app, String marked, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency, Nullable`1 postTimeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 797
   at Microsoft.Maui.TestCases.Tests.ButtonFeatureTests.Button_SetLineBreakModeTailTruncation_VerifyVisualState() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/ButtonFeatureTests.cs:line 362
   at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack targe
...

This uitests section was shortened to keep every required review section visible. Full details remain available in the pipeline build artifacts.


🧭 Next Steps — reviewer changes required

The reviewer-enhanced candidate identified changes that are not yet in the submitted PR.

Why: The reviewer-refined PR fixes the submitted implementation's null-name crash and unbounded process-wide string retention while preserving virtual dispatch and per-BindableProperty hot-path reuse. It is the only materialized alternative to pass the required targeted suite: 111 passed, 0 failed.

Address the actionable findings in this review before merging.

@MauiBot MauiBot added s/agent-changes-requested AI agent recommends changes - found a better alternative or issues and removed s/agent-review-in-progress AI review is currently running for this PR labels Aug 22, 2026
@pictos

pictos commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

was looking at your PR description (#34155). And looking into the raws benchmarks, I notice that some of them have unreliable data. For example, this one

Method ChildCount Mean Error StdDev Gen0 Allocated
FlexCoreMeasureArrange 12 853 ns 163 ns 9.0 ns 0.0353 224 B
FlexCoreMeasureArrange 60 6,661 ns 10,747 ns 589.1 ns 0.1297 832 B

where for ChildCount the error is 161% . When Error is >= the Mean, that indicates that the result isn't reliable. The code here could really improve the performance, but it's against the wrong measure, so I would suggest to fix the benchmark before merging or doing further changes here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

copilot perf/general The issue affects performance (runtime speed, memory usage, startup time, etc.) (sub: perf) s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-fix-win AI found a better alternative fix than the PR s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants